// Copyright 2023 The Flutter Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.

/// @docImport '../framework/screen.dart';
library;

import 'dart:async';

import 'package:devtools_app_shared/service.dart';
import 'package:devtools_app_shared/utils.dart';
import 'package:flutter/foundation.dart';

import '../config_specific/import_export/import_export.dart';
import '../framework/routing.dart';
import '../framework/screen_controllers.dart';
import '../globals.dart';

/// Controller that manages offline mode for DevTools.
///
/// This class will be instantiated once and set as a global [offlineDataController]
/// that can be accessed from anywhere in DevTools.
class OfflineDataController {
  /// Whether DevTools is in offline mode.
  ///
  /// We consider DevTools to show offline data whenever there is data
  /// that was previously saved from DevTools.
  ///
  /// The value of [showingOfflineData] is independent of the DevTools connection
  /// status. DevTools can be in offline mode both when connected to an app when
  /// disconnected from an app.
  ValueListenable<bool> get showingOfflineData => _showingOfflineData;
  final _showingOfflineData = ValueNotifier<bool>(false);

  /// The current offline data as raw JSON.
  ///
  /// This value is set from [ImportController.importData] when offline data is
  /// imported to DevTools.
  var offlineDataJson = <String, Object?>{};

  /// Stores the [ConnectedApp] instance temporarily while switching between
  /// offline and online modes.
  ///
  /// We store this because the `serviceConnection.serviceManager` is a global
  /// manager and expects only one connected app. So we swap out the online
  /// connected app with the offline app data while in offline mode.
  ConnectedApp? previousConnectedApp;

  /// Whether DevTools should load offline data for [screenId].
  bool shouldLoadOfflineData(String screenId) {
    return _showingOfflineData.value &&
        offlineDataJson.isNotEmpty &&
        offlineDataJson[screenId] != null;
  }

  void startShowingOfflineData({required ConnectedApp offlineApp}) {
    previousConnectedApp = serviceConnection.serviceManager.connectedApp;
    serviceConnection.serviceManager.connectedApp = offlineApp;
    _showingOfflineData.value = true;
  }

  void stopShowingOfflineData() {
    serviceConnection.serviceManager.connectedApp = previousConnectedApp;
    _showingOfflineData.value = false;
    offlineDataJson.clear();
    previousConnectedApp = null;
  }
}

/// Mixin that provides offline support for a DevTools screen controller.
///
/// The [Screen] that is associated with this controller must have
/// [Screen.worksWithOfflineData] set to true in order to enable offline support for the
/// screen.
///
/// Check [OfflineDataController.showingOfflineData] in controller constructor.
/// If it is true, the screen should ignore the connected application and just show
/// the offline data.
///
/// If a screen controller (A) is created for offline mode while another
/// instance of this screen controller (B) exists for interacting
/// with the current DevTools connection, screen controller (B) should
/// continue to work as it normally would in the background. This will
/// ensure that the user can return to what they were looking at
/// previously before entering offline mode to view offline data.
///
/// Example:
///
/// ```dart
/// class MyScreenController with OfflineScreenControllerMixin<MyScreenData> {
///   MyScreenController() {
///     init();
///   }
///
///   void init() {
///     if (offlineDataController.showingOfflineData.value) {
///       await maybeLoadOfflineData(
///         ScreenMetaData.myScreen.id,
///         createData: (json) => MyScreenData.parse(json),
///         shouldLoad: (data) => data.isNotEmpty,
///         loadData: (data) async {
///           // Set up the all the data models and notifiers that feed MyScreen's UI.
///         },
///       );
///     } else {
///       // Do screen initialization for connected application.
///     }
///   }
///
///   // Override the abstract methods from [OfflineScreenControllerMixin].
///
///   @override
///   OfflineScreenData prepareOfflineScreenData() => OfflineScreenData(
///     screenId: ScreenMetaData.myScreen.id,
///     data: {} // The data for this screen as a serializable JSON object.
///   );
/// }
/// ```
///
/// ...
///
/// Then in the DevTools [ScreenMetaData] enum,
/// set `worksWithOfflineData` to `true`.
///
/// ```dart
/// enum ScreenMetaData {
///   ...
///   myScreen(
///     ...
///     worksWithOfflineData: true,
///   ),
/// }
/// ```
mixin OfflineScreenControllerMixin<T>
    on DevToolsScreenController, AutoDisposeControllerMixin {
  final _exportController = ExportController();

  /// Whether this controller is actively loading offline data.
  ///
  /// It is likely that a screen will want to show a loading indicator in place
  /// of its normal UI while this value is true.
  ValueListenable<bool> get loadingOfflineData => _loadingOfflineData;
  final _loadingOfflineData = ValueNotifier<bool>(false);

  /// Returns an [OfflineScreenData] object with the data that should be
  /// included in the offline data snapshot for this screen.
  OfflineScreenData prepareOfflineScreenData();

  /// Loads offline data for [screenId] when available, and when the
  /// [shouldLoad] condition is met.
  ///
  /// Screen controllers that mix in [OfflineScreenControllerMixin] should call
  /// this during their initialization when DevTools is in offline mode,
  /// defined by [OfflineDataController.showingOfflineData].
  ///
  /// [loadData] defines how the offline data for this screen should be
  /// processed and set.
  /// Each screen controller that mixes in [OfflineScreenControllerMixin] is
  /// responsible for setting up the data models and feeding the data to the
  /// screen for offline viewing - that should occur in this method.
  ///
  /// Returns true if offline data was loaded, false otherwise.
  @protected
  Future<bool> maybeLoadOfflineData(
    String screenId, {
    required T Function(Map<String, Object?> json) createData,
    required bool Function(T data) shouldLoad,
    required FutureOr<void> Function(T data) loadData,
  }) async {
    if (offlineDataController.shouldLoadOfflineData(screenId)) {
      // TODO(kenz): investigate this line of code. Do we need to be creating a
      // second copy of the Map from offlineDataController.offlineDataJson or
      // can we use it directly to save this `Map.of` call?
      final json = Map<String, Object?>.of(
        (offlineDataController.offlineDataJson[screenId] as Map)
            .cast<String, Object?>(),
      );
      final screenData = createData(json);
      if (shouldLoad(screenData)) {
        _loadingOfflineData.value = true;
        await loadData(screenData);
        _loadingOfflineData.value = false;
        return true;
      }
    }
    return false;
  }

  /// Exports the current screen data to a .json file and downloads the file to
  /// the user's Downloads directory.
  void exportData() {
    final encodedData = _exportController.encode(
      prepareOfflineScreenData().toJson(),
    );
    _exportController.downloadFile(encodedData);
  }

  /// Prepare the screen's current data for offline viewing after an app
  /// disconnect.
  ///
  /// This is in preparation for the user clicking the 'Review History' button
  /// from the disconnect screen.
  void maybePrepareDataForReviewingHistory() {
    final currentScreenData = prepareOfflineScreenData();
    // Only store data for the current page. We can change this in the
    // future if we support offline imports for more than once screen at a
    // time.
    if (DevToolsRouterDelegate.currentPage == currentScreenData.screenId) {
      final previouslyConnectedApp = offlineDataController.previousConnectedApp;
      final offlineData = _exportController.generateDataForExport(
        offlineScreenData: currentScreenData.toJson(),
        connectedApp: previouslyConnectedApp,
      );
      offlineDataController.offlineDataJson = offlineData;
    }
  }
}

/// Stores data for a screen that will be used to create a DevTools data export.
class OfflineScreenData {
  OfflineScreenData({required this.screenId, required this.data});

  /// The screen id that this data is associated with.
  final String screenId;

  /// The JSON serializable data for the screen.
  ///
  /// This data will be encoded as JSON and written to a file when data is
  /// exported from DevTools. This means that the values in [data] must be
  /// primitive types that can be encoded as JSON.
  final Map<String, Object?> data;

  Map<String, Object?> toJson() => {
    DevToolsExportKeys.activeScreenId.name: screenId,
    screenId: data,
  };
}
